Skip to content

[JSC] Arrow functions and eval code of a derived constructor read this at each use when other code of the constructor can call super() - #1018

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/e0501bca/derived-constructor-this-read-at-use
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/e0501bca/derived-constructor-this-read-at-use

Conversation

@robobun

@robobun robobun commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

Draft: an upstream candidate (Route in Notes).

Problem

  • In a derived constructor, an arrow function that reads this after another one called super() throws ReferenceError: 'super()' must be called in derived constructor before accessing |this| or returning non-object. Node returns the object. esbuild's __super has this shape (fix: do not read this before super() in wrapped constructors nodejs/orchestrion-js#101).
  • An arrow function copies this at its start. ensureThis() (BytecodeGenerator.cpp:5442) reads again only in code that itself holds super() or an eval.

Fix

  • A derived constructor flags its arrow functions and eval code when one of them can call super(). Flagged code reads this from the scope at each use.
  • There, a super property checks this first (from [JSC] Super property should check this binding before evaluating the property expression WebKit/WebKit#76967).
  • The debugger reads this of a flagged or constructor frame from the scope.
  • Verified: JSTests/stress/derived-constructor-this-initialized-by-another-arrow-function.js (new, fails on the base). Self-reviewed: 24 concerns raised, 22 addressed (Notes).

Background

  • super() initializes this of a derived constructor. JSC keeps it in a scope slot for its arrow functions.
  • Considered it for every such arrow function: 469 constructors of an npm sample pay, 1 needs it.

Downsides

Notes

Route. This PR does not ask for a merge into the fork as it stands.

What changes. The code is in the constructor of class K extends B, and cs is () => { super(); }. The base is 5a61071.

Code base this PR Node v26.3.0
const f = () => { cs(); return this; }; f() ReferenceError the new object the new object
const f = () => (() => { cs(); return this; })(); f() ReferenceError the new object the new object
eval("cs(); this") ReferenceError the new object the new object
const f = () => { super[(cs(), "x")] = 1; }; f() ReferenceError, after the constructor of B ran ReferenceError, the constructor of B does not run as this PR
const f = () => { super[(super(), "x")] = 1; }; f() stores ReferenceError, the constructor of B does not run as this PR
const f = () => this; f() before any super() ReferenceError ReferenceError ReferenceError
  • The fifth row is one of 14 forms in which the arrow function itself holds the super(): stores, compound assignments, ++, delete, a template tag, destructuring, for-of and for-in targets. Such an arrow function reads this again at the store on the base too. 13 forms store there and 1 throws for another reason. Here all 14 throw before the key is evaluated.
  • 23 store forms in a sync arrow function, an async arrow function without await, and an async arrow function before and after an await: this PR differs from Node in one, delete super[key], where V8 evaluates the key first.

Which code has the bit.

  • An arrow function of a derived constructor, at any depth of arrow functions, when an arrow function of the constructor has super() or a direct eval, or the constructor has a direct eval (isSuperCallUsedInInnerArrowFunction()).
  • Eval code of a derived constructor, called from the constructor or from code with the bit, and the arrow functions of such eval code.
  • Not: code of a class with no parent, a method, an ordinary function in a constructor, indirect eval.
  • Code with the bit that uses neither this nor a super property compiles as before.

Cost. Release jsc of both commits. The counts are instructions:u for one call of the arrow function from a loop, the difference of two call counts. Baseline varies by about 3 between runs.

Arrow function LLInt Baseline DFG FTL
not flagged, 1 or 4 reads of this.v, base and this PR 379, 611 108, 193 26, 33 6.5, 6.5
flagged, 1 read 379 → 384 108 → 119 26 → 26 6.5 → 6.5
flagged, 4 reads 611 → 699 193 → 216 33 → 74 6.5 → 6.5
flagged, 4 reads with a call between them 1298 → 1386 390 → 414 204 → 226 103 → 127
  • The 74 is the arrow function not inlined: 100 bytes against the limit of 80 (maximumFunctionForCallInlineCandidateBytecodeCostForDFG). With the limit at 120 it is 33.
  • Size of a flagged arrow function with 1, 2 and 4 reads: 28, 43, 73 bytes on the base and 28, 52, 100 bytes here. It has 6, 10, 18 instructions against 6, 9, 15, and one more variable.
  • A store to a super property in flagged code: 57 → 69 bytes, 11 → 13 instructions. The check at the store stays, as in [JSC] Super property should check this binding before evaluating the property expression WebKit/WebKit#76967.
  • Eval code this.v in a derived constructor: 18 → 34 bytes. this.v + this.v: 33 → 58 bytes. The same text in the constructor of a class with no parent: 18 and 33 bytes, unchanged.
  • Eval code of a derived constructor, one read of this.own in a loop, instructions for one iteration:
Eval code LLInt Baseline DFG FTL
with a var or a function of its own 169 → 648 48 → 483 18 → 277 6.5 → 272
without 169 → 197 45 → 46 18 → 22 6.5 → 9
  • The first row is a lookup out of line: the scope of the eval code's own variables is between the code and the constructor, so the read cannot be resolved when the code is linked. A read of a variable of the constructor in that eval code takes 658 instructions in FTL, before and after. Arrow functions and eval code inside such eval code are in the same case.
  • An npm sample (2,620 files): 6,485 derived constructors, 469 with arrow functions that read this, 1 flagged (the __super helper in prettier 3.6.2). Constructors with a direct eval: 0. Stores to a super property in such code: 0.
  • size of release jsc: text 51,700,256 → 51,701,984.
  • sizeof(UnlinkedFunctionExecutable), sizeof(UnlinkedCodeBlock) and sizeof(EvalExecutable) do not change: the bit is in bit-fields with room.
  • Considered a read only while the copy in the frame is empty. It takes the lookup away after the first read. It needs the copy at the start again, and two more instructions at each use. Not built.

Other code. jsc --useJIT=0 --dumpGeneratedBytecodes=1 over 545 files of JSTests/stress (arrow functions, classes, super, eval, TDZ): 21,277 of 21,297 code blocks are the same on both builds. The 20 others are 14 arrow functions of flagged constructors and 6 eval code blocks of derived constructors.

The bit and not a fourth DerivedContextType. A fourth value needs a change at each comparison with DerivedConstructorContext, in the parser, the generator and the interpreter. A comparison that a later sync brings would be wrong for the new value, with no build error. A separate bit changes no comparison.

The debugger.

  • DebuggerCallFrame::thisValue() reads the scope for a frame whose code block has the bit. Such a frame has a copy of this only after a read. Before that its slot has the receiver of the call.
  • It reads the scope for the frame of a derived constructor too, while that frame has no copy. On the base the debugger shows this as undefined there after an arrow function called super(), until the constructor uses this. That is the state of a constructor right after a call of esbuild's __super. The walk ends at the scope of the callee, so a constructor finds only a this of its own activation.
  • DebuggerCallFrame::evaluateWithScopeExtension() hands the bit of the paused frame to the eval code that it makes: console text, the condition and the action of a breakpoint. Text that calls super() and then reads this gets the object.
  • Not changed: boundThis of an arrow function in the inspector reads the scope of the arrow function, and the request aborts the process while this has no value, on the base too. [JSC] Show boundThis of an arrow function as undefined in the inspector while this of its derived constructor has no value #1019 fixes the abort.

The bytecode cache. The block with builtinFromBytecodeCache in the test makes code after a decode. It fails when either flag is not packed or not unpacked: the function executable's flag at depth 1, the code block's flag at depth 2 in the eval case. Scalars is value-initialized in the two decoder views for that: without it a flag that is not unpacked is what the stack had. With it the block fails in 5 of 5 runs when either unpack line is removed.

Not in this PR.

Self-review. Two rounds, 24 concerns, 22 addressed.

Test runs.

@robobun
robobun requested a review from sosukesuzuki November 9, 2026 00:46
…is` at each use when other code of the constructor can call super()

In a derived constructor, an arrow function copies `this` from the scope of the constructor when it starts,
and eval code gets `this` from its caller. Other code of the constructor can call super() after that:

    class K extends B {
        constructor()
        {
            const callSuper = () => { super(); };
            const read = () => { callSuper(); return this; };
            read();
        }
    }

read() throws "ReferenceError: 'super()' must be called in derived constructor before accessing |this| or
returning non-object." `this` of the constructor is one binding (ECMA-262 GetThisBinding), and V8 returns the
object. esbuild's `__super` helper for lowered class fields has this shape.

BytecodeGenerator::ensureThis() reads `this` again from the scope only where the code block itself has a
super() or an eval, or an arrow function with one. A code block does not know what its parents and the other
arrow functions of the constructor have.

The derived constructor knows: isSuperCallUsedInInnerArrowFunction(). It tells its arrow functions with one
bit, derivedContextHasInnerSuperCall, and they hand it on to their arrow functions. Eval code gets the bit
from its caller: from an arrow function or eval code what that has, and from a constructor whether it has a
parent class. The bit goes through UnlinkedFunctionExecutable, DirectEvalExecutable, ExecutableInfo,
UnlinkedCodeBlock and the bytecode cache.

Code with the bit that uses `this` resolves the scope that has `this` once, into a variable, and makes no copy
at its start. ensureThis() reads `this` from that scope each time: one get_from_scope in front of the check.
The body of an async arrow function keeps its form, which reads `this` at each use already. Code without the
bit compiles to the bytecode it had: an arrow function of a derived constructor in which no arrow function and
no eval can call super(), and eval code in the constructor of a class that has no parent.

In code with the bit, a super property checks `this` before its key and the assigned value are evaluated
(SuperNode::emitBytecode). `super[(callSuper(), key)] = value` threw only because the check at the store saw
the old copy. The two lines are from WebKit#76967, which makes that check in all code. The body of the
constructor, and code without the bit, have only the check at the store, as before.

The debugger takes `this` of a frame from the frame. A frame of code with the bit has a copy only after a
read. The frame of the constructor has none between a super() in its arrow function or eval code and its next
use of `this`, which the debugger showed as undefined. DebuggerCallFrame::thisValue() reads the scope for both.
Console text, and the condition and the action of a breakpoint, are eval code of the paused frame:
DebuggerCallFrame::evaluateWithScopeExtension() gives them the bit of that frame.

cachedTypesFormatRevision is 15: the flags of a function executable and of a code block have the bit. The
decoder's Scalars are value-initialized, so a flag that a later change does not unpack is 0 and not what the
stack had.

Test: JSTests/stress/derived-constructor-this-initialized-by-another-arrow-function.js

Co-authored-by: SUZUKI Sosuke <sosuke@bun.com>
@robobun

robobun commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on the base (5a61071) with jsc and with Bun 1.4.3:

class B {}
class K extends B {
  constructor() {
    const callSuper = () => { super(); };
    const f = () => { callSuper(); return this; };
    const r = f();
    console.log("K ok", r === this);
  }
}
try { new K(); } catch (e) { console.log("K THROWS", e.constructor.name + ": " + e.message); }
  • Base: K THROWS ReferenceError: 'super()' must be called in derived constructor before accessing |this| or returning non-object.
  • This branch, and Node v26.3.0: K ok true

Related PRs: #1016 (a register that a read of this can take), #1017 (the DFG recompiles an arrow function that calls super()), #1019 (the inspector aborts on boundThis before super()). The Bun PR that pins the preview build of this branch, with the tests on that side: oven-sh/bun#48304.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

Preview build of 6ada7bd: autobuild-preview-pr-1018-6ada7bda

robobun added a commit to oven-sh/bun that referenced this pull request Nov 9, 2026
…ived constructor sees `this` after another one called super()

In a derived constructor, an arrow function that reads `this` after another arrow function of the constructor
called super() threw "ReferenceError: 'super()' must be called in derived constructor before accessing |this|
or returning non-object." oven-sh/WebKit#1018 makes such arrow functions, and eval code, read `this` at each
use. WEBKIT_VERSION is the preview build of that pull request.

The tests:
- test/js/bun/jsc-stress/fixtures/derived-constructor-this-initialized-by-another-arrow-function.js is the
  stress test of that pull request.
- test/cli/inspect/inspect.test.ts pauses in such an arrow function, in eval code of one and in the
  constructor, and reads `this` of the frame and from console text.
- test/bundler/bundler_bun.test.ts builds bytecode of depth 1, 2 and 3: the cache keeps what the constructor
  told its arrow functions.
- test/aot/parallel/classes has a program that plain Bun and an executable of --aot print alike, and one with
  a Todo line for the order of the check in a store to a super property.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant